Bulk action bar
Let users check multiple rows and run one action (edit, tag, export, delete) on all of them at once.
How it works
The BulkActionBar is an addition, not a takeover: it appears in the table's own row when a selection starts, offers the actions that apply to that selection, and gets out of the way when the selection ends. It holds no state of its own.
Where it appears
The bar appears in its own row between the Table toolbar and the table's column headers as soon as at least one row is selected. The toolbar (filter and search) stays live above it, and the bar pushes the table down without overlapping.
Dismissing the bar, either with the × button or with Esc once the scope menu is closed, clears the selection and collapses it.
The bar owns no state
The bar is presentational: it owns no state. It renders the scope menu, the "Select all" shortcut, the actions, and the dismiss control, and calls back to your handlers, mirroring how Table leaves row selection to the consumer.
It renders nothing at all while selectedCount is 0, so it appears with the first selected row and disappears with the last without you gating it.
Managing selection with useBulkActions
Selection state lives outside the bar. The shipped useBulkActions hook can manage it for you. It tracks selection as an include/exclude predicate and derives the counts, the page-header tri-state, and the scope handlers. It's built for server-side pagination: you pass the server's totalCount and only ever hand it the current page, so "select all" never enumerates rows you haven't loaded.
Spread bulk.getBarProps(pageItems) into the bar and add your own actions / destructiveAction. To act on the selection, read bulk.selection (send it to your backend) or, client-side, items.filter((i) => bulk.isSelected(i) === true).
You can also skip the hook and pass the props yourself. Either way the bar never owns the state.
Selection in server-side tables
Three things are worth knowing about the hook when the table is paginated server-side.
- Tri-state selection.
isSelectedreturnstrue/false/"partial", so a parent row with only some children selected can drive an indeterminate checkbox. Flag it withbulk.setPartial(item, true); the hook stores the flag (what "partial" means is your domain) and never counts a partial item as a whole one. Because"partial"is truthy, always compare with=== true. - Hydrating a pre-selection. Pre-selected state that arrives from an async fetch after mount can be hydrated with
bulk.seed(selection), which takes ids, not items, so it works when the pre-selected rows live on pages you haven't loaded. - The count is capped.
selectedCountis capped attotalCount, so a seeded selection that a later filter narrows can never report more than the total (it may still overcount rows outside the filter; an exact count needs a server-side intersection).
Examples
Anatomy
The bar starts with a button showing how many items are selected — "4 selected" below — which opens the scope menu. The "Select all" shortcut next to it mirrors the menu's most-used command in one click, and hides once everything is selected. Actions and the destructive action sit in their own divider-separated groups, and the icon-only dismiss button ends the bar.
The scope menu lists only the commands that would change the selection: with the whole page already selected, "Select current page" is not there rather than greyed out. Each command's count is the number of items it would affect — with 21 rows selected out of 33, the menu offers "Deselect all items (21)".
("Select 4 sites" appears in this example only while nothing is selected. It stands in for ticking rows in a real table, so you can bring the bar back after dismissing it.)
const totalCount = 33;
const pageTotal = 10;
const [selectedCount, setSelectedCount] = useState(4);
const [selectedOnPage, setSelectedOnPage] = useState(4);
// Tag runs a short async task, to show an action in its loading state.
const [tagging, setTagging] = useState(false);
const { requestDelete, modal } = useConfirmDelete();
const clearAll = () => {
setSelectedCount(0);
setSelectedOnPage(0);
};
return (
<>
{/* The bar renders nothing at 0 selected, so this example needs a way back. In a real table
that's the row checkboxes. */}
{selectedCount === 0 && (
<Button
onClick={() => {
setSelectedCount(4);
setSelectedOnPage(4);
}}
>
Select 4 sites
</Button>
)}
<BulkActionBar
selectedCount={selectedCount}
totalCount={totalCount}
selectedOnPageCount={selectedOnPage}
pageItemCount={pageTotal}
onSelectPage={() => {
setSelectedOnPage(pageTotal);
setSelectedCount((c) => Math.max(c, pageTotal));
}}
onDeselectPage={() => {
setSelectedCount((c) => c - selectedOnPage);
setSelectedOnPage(0);
}}
onSelectAll={() => {
setSelectedCount(totalCount);
setSelectedOnPage(pageTotal);
}}
onDeselectAll={clearAll}
actions={[
{
text: "Tag",
icon: <IconLabel />,
loading: tagging,
onClick: () => {
setTagging(true);
window.setTimeout(() => setTagging(false), 1500);
},
},
{
text: "Export",
icon: <IconDownload />,
onClick: () => console.log("Export"),
},
]}
destructiveAction={{
text: "Delete",
onClick: () => requestDelete(selectedCount, clearAll),
}}
onDismiss={clearAll}
/>
{modal}
</>
);Grouped sub-actions
Some actions have more than one way to run — Delete can mean delete the item, or delete the item and its data. Give the action subActions and the bar groups them under a chevron instead of adding a second button, labelling that chevron for you. The main button stays the common choice, and the same grouping works on any action with variants — Edit, Add, Share.
const { requestDelete, modal } = useConfirmDelete();
const barProps = {
selectedCount: 4,
totalCount: 33,
selectedOnPageCount: 4,
pageItemCount: 12,
onSelectPage: () => undefined,
onDeselectPage: () => undefined,
onSelectAll: () => undefined,
onDeselectAll: () => undefined,
onDismiss: () => undefined,
};
return (
<>
<BulkActionBar
{...barProps}
destructiveAction={{
text: "Delete",
onClick: () => requestDelete(4, () => undefined),
subActions: [
{
text: "Delete and remove data",
onClick: () => requestDelete(4, () => undefined, true),
},
],
}}
/>
<BulkActionBar
{...barProps}
actions={[
{
text: "Edit",
onClick: () => console.log("Edit"),
subActions: [{ text: "Edit metadata", onClick: () => console.log("Edit metadata") }],
},
]}
/>
{modal}
</>
);Usage with a table, toolbar, and actions
A full example: a TableToolbar (with a filter and search) sits above the bar, and the bar sits above the Table. "Tag" and "Export" are ordinary actions (here Export downloads the selected rows as CSV), and "Delete" opens a confirm modal.
Add a leading checkbox column. The header checkbox is tri-state and page-scoped only (empty selects the page, a dash selects the rest of the page, a check clears the page).
Selection persists when you turn a page but clears when you change the filter, search, or page size. In a real, server-side-paginated table the page holds only the loaded rows, while "Select all" is a server-side predicate that covers the whole filtered set.
Careers | careers.example.com | Corporate | |
|---|---|---|---|
Community forum | community.example.com | Product | |
Company blog | blog.example.com | Marketing | |
Developer docs | docs.example.com | Product | |
Events | events.example.com | Marketing |
const [sort, setSort] = useState<SortField<Site>>({ property: "name", direction: "asc" });
const [category, setCategory] = useState<Category | undefined>(undefined);
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(PAGE_SIZE_DEFAULT);
const [message, setMessage] = useState<string | null>(null);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const filtered = useMemo(
() =>
allSites
.filter((s) => !category || s.category === category)
.filter((s) => `${s.name} ${s.url}`.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => compare(a, b, sort)),
[category, query, sort]
);
// Opens with two rows already selected, so the bar is on screen from the start.
const bulk = useBulkActions(filtered.length, (s: Site) => s.id, { initialSelectedIds: [6, 10] });
// Dismissing unmounts the bar, so hand focus back to the page-header checkbox. BaseCheckbox
// renders the <input> itself and takes no ref, hence the wrapper.
const headerCheckboxRef = React.useRef<HTMLSpanElement>(null);
const focusHeaderCheckbox = () => headerCheckboxRef.current?.querySelector("input")?.focus();
const pageItems = filtered.slice((page - 1) * pageSize, page * pageSize);
const barProps = bulk.getBarProps(pageItems);
const { allSelected: pageAllSelected, someSelected: pageSomeSelected } =
bulk.pageState(pageItems);
const resetView = () => {
bulk.clear();
setPage(1);
};
const exportCsv = useCsvExporter<Site>([
{ header: "Name", render: (s) => s.name },
{ header: "URL", render: (s) => s.url },
{ header: "Category", render: (s) => s.category },
]);
const [filterButton, activeFilters] = useSingleFilter<Category>(
category,
(value) => {
setCategory(value);
resetView();
},
{
label: "Category",
name: "category",
stringify: (c) => c ?? "",
items: categories.map((c) => ({ title: c, value: c })),
compareFn: (a, b) => a === b,
}
);
return (
<div>
{message && (
<Message type="positive" dismissible onDismiss={() => setMessage(null)}>
{message}
</Message>
)}
<TableToolbar
actions={<Button variant="primary">+ Add</Button>}
customViews={<Button>Custom views</Button>}
filter={filterButton}
activeFilters={activeFilters}
search={
<InputField
aria-label="Search sites"
placeholder="Search sites"
value={query}
onChange={(value) => {
setQuery(value);
resetView();
}}
/>
}
/>
<BulkActionBar
{...barProps}
actions={[
{
text: "Tag",
icon: <IconLabel />,
// A successful action confirms with a count and clears the selection, which takes the
// bar with it. Export doesn't, since it changes nothing.
onClick: () => {
setMessage(`${barProps.selectedCount} sites tagged.`);
bulk.clear();
},
},
{
text: "Export",
icon: <IconDownload />,
onClick: () => {
// Client-side: filter the full array with the predicate. Server-side you'd send
// `bulk.selection` to the backend instead.
exportCsv(
filtered.filter((s) => bulk.isSelected(s) === true),
{ fileName: "sites.csv" }
);
setMessage(`${barProps.selectedCount} sites exported to CSV.`);
},
},
]}
destructiveAction={{
text: "Delete",
onClick: () => setConfirmDeleteOpen(true),
}}
onDismiss={() => {
bulk.clear();
focusHeaderCheckbox();
}}
/>
<Table
items={pageItems}
loading={false}
sort={sort}
setSort={(property, direction) =>
setSort({
property,
direction: property === sort.property ? invertDirection(sort.direction) : direction,
})
}
rowKey={(site) => site.id}
highlightRow={(site) => bulk.isSelected(site) === true}
columns={[
{
header: {
contentNode: (
<span ref={headerCheckboxRef}>
<BaseCheckbox
aria-label="Select all rows on this page"
checked={pageAllSelected}
indeterminate={pageSomeSelected}
onChange={() =>
pageAllSelected ? barProps.onDeselectPage() : barProps.onSelectPage()
}
/>
</span>
),
},
render: (site) => (
<BaseCheckbox
aria-label={`Select ${site.name}`}
checked={bulk.isSelected(site) === true}
onChange={() => bulk.toggleRow(site)}
/>
),
options: { width: 48, align: "center" },
},
{
header: { property: "name", content: "Name", defaultSortDirection: "asc" },
render: (site) => site.name,
options: { isKeyColumn: true },
},
{
header: { property: "url", content: "URL", defaultSortDirection: "asc" },
render: (site) => site.url,
},
{
header: { property: "category", content: "Category", defaultSortDirection: "asc" },
render: (site) => site.category,
},
]}
pagination={{
total: filtered.length,
page,
setPage, // selection persists across pagination
pageSize,
setPageSize: (size) => {
setPageSize(size);
resetView(); // changing page size clears the selection
},
cancelLabel: "Cancel",
confirmLabel: "Confirm",
firstLabel: "First",
prevLabel: "Previous",
nextLabel: "Next",
lastLabel: "Last",
pagingInfoLabel: (startIdx, endIdx, total) => `${startIdx} - ${endIdx} of ${total} sites`,
pageLabel: "Page",
pageXofYLabel: (current, total) => `Page ${current} of ${total}`,
pageSizeSelectionLabel: (size) => `${size} items`,
pageSizeSelectorPrefix: "Show",
pageSizeSelectorPostfix: "per page",
pageSizeLabel: "Items per page",
defaultError: "Invalid page number",
wholeNumberError: "Must be a whole number",
outOfBoundsError: (total) => `Enter a number between 1 and ${total}`,
}}
/>
<Modal
shown={confirmDeleteOpen}
headerTitle={`Delete ${barProps.selectedCount} sites?`}
onClose={() => setConfirmDeleteOpen(false)}
>
<Modal.Content>
<Paragraph>
The {barProps.selectedCount} selected sites will be permanently deleted. This action
cannot be undone.
</Paragraph>
</Modal.Content>
<Modal.Footer>
<DestructiveActionBar
cancel={{ children: "Cancel", onClick: () => setConfirmDeleteOpen(false) }}
destructive={{
children: "Delete",
onClick: () => {
setConfirmDeleteOpen(false);
setMessage(`${barProps.selectedCount} sites deleted.`);
bulk.clear();
},
}}
/>
</Modal.Footer>
</Modal>
</div>
);Usage with a List table
The pattern works the same above a List table. A List table has no column-header checkbox, so page/all selection is driven entirely by the scope menu — the rows still carry their own checkboxes. Switching tab changes the result set, so the selection resets with it, and because there is no header checkbox to return to, dismissing the bar hands focus back to the toolbar.
Name | URL | Category | |
|---|---|---|---|
Marketing site | marketing.example.com | Marketing | |
Support portal | support.example.com | Product | |
Developer docs | docs.example.com | Product | |
Company blog | blog.example.com | Marketing |
const [page, setPage] = useState(1);
const [tab, setTab] = useState(0);
const { requestDelete, modal } = useConfirmDelete();
// Opens with one row selected, so the bar is on screen from the start.
const bulk = useBulkActions(allSites.length, (s: Site) => s.id, { initialSelectedIds: [1] });
const pageItems = allSites.slice((page - 1) * LIST_PAGE_SIZE, page * LIST_PAGE_SIZE);
const total = allSites.length;
const barProps = bulk.getBarProps(pageItems);
// A list table has no header checkbox, so focus goes back to the toolbar on dismiss.
const toolbarRef = React.useRef<HTMLDivElement>(null);
const focusToolbar = () => toolbarRef.current?.querySelector("button")?.focus();
const list = (
<div>
<div ref={toolbarRef}>
<TableToolbar
actions={<Button variant="primary">+ Add</Button>}
search={<InputField aria-label="Search" placeholder="Search" value="" onChange={noop} />}
/>
</div>
<BulkActionBar
{...barProps}
actions={[
{
text: "Share",
icon: <IconLabel />,
onClick: () => console.log("Share", barProps.selectedCount),
},
]}
destructiveAction={{
text: "Delete",
onClick: () => requestDelete(barProps.selectedCount, bulk.clear),
}}
onDismiss={() => {
bulk.clear();
focusToolbar();
}}
/>
<ListTable
items={pageItems}
loading={false}
columns={[
{
header: { content: "" },
render: (site) => (
<BaseCheckbox
aria-label={`Select ${site.name}`}
checked={bulk.isSelected(site) === true}
onChange={() => bulk.toggleRow(site)}
/>
),
options: { width: 48, align: "center" },
},
{
header: { content: "Name" },
render: (site) => site.name,
options: { isKeyColumn: true },
},
{
header: { content: "URL" },
render: (site) => site.url,
},
{
header: { content: "Category" },
render: (site) => site.category,
},
]}
pagination={{
total,
page,
setPage, // selection persists across pagination
pageSize: LIST_PAGE_SIZE,
setPageSize: null,
cancelLabel: "Cancel",
confirmLabel: "Confirm",
firstLabel: "First",
prevLabel: "Previous",
nextLabel: "Next",
lastLabel: "Last",
pagingInfoLabel: (startIdx, endIdx, count) => `${startIdx} - ${endIdx} of ${count} sites`,
pageLabel: "Page",
pageXofYLabel: (current, count) => `Page ${current} of ${count}`,
pageSizeSelectionLabel: (size) => `${size} items`,
pageSizeSelectorPrefix: "Show",
pageSizeSelectorPostfix: "per page",
pageSizeLabel: "Items per page",
defaultError: "Invalid page number",
wholeNumberError: "Must be a whole number",
outOfBoundsError: (count) => `Enter a number between 1 and ${count}`,
}}
/>
{modal}
</div>
);
return (
<Tabs
selectedTab={tab}
// Switching tab changes the result set, so the selection resets with it.
onChange={(next: number) => {
setTab(next);
bulk.clear();
setPage(1);
}}
tabs={[
{ header: "Active", content: list },
{ header: "Archived", content: <Paragraph>No archived sites.</Paragraph> },
]}
/>
);View selected only
The optional viewSelectedOnly toggle narrows the table to the selected rows so the user can check the selection before acting on it. It is worth adding when the selection can reach beyond the current page. Filtering the rows is your job, not the bar's.
The bar is the toggle's only home, so clearing the selection takes the toggle with it. Give the table a noDataState with a way back rather than leaving a blank table behind.
Select | Site |
|---|---|
careers.example.com | |
community.example.com |
const [viewSelected, setViewSelected] = useState(true);
const bulk = useBulkActions(allSites.length, (s: Site) => s.id, { initialSelectedIds: [6, 10] });
const visible = viewSelected ? allSites.filter((s) => bulk.isSelected(s) === true) : allSites;
const barProps = bulk.getBarProps(visible);
return (
<div>
<BulkActionBar
{...barProps}
actions={[{ text: "Export", onClick: noop }]}
viewSelectedOnly={{ value: viewSelected, onChange: setViewSelected }}
onDismiss={bulk.clear}
/>
<Table
items={visible}
loading={false}
sort={null}
setSort={noop}
rowKey={(site) => site.id}
highlightRow={(site) => bulk.isSelected(site) === true}
// With the filter on and nothing selected the bar unmounts, taking the toggle with it. Show
// the table's empty state with a way back rather than a blank table.
noDataState={
<EmptyState
type="reassure"
description="No rows match the current selection."
button={{ text: "Show all rows", onClick: () => setViewSelected(false) }}
/>
}
columns={[
{
header: srOnlyHeader("Select"),
render: (site) => (
<BaseCheckbox
aria-label={`Select ${site.name}`}
checked={bulk.isSelected(site) === true}
onChange={() => bulk.toggleRow(site)}
/>
),
options: { width: 48, align: "center" },
},
{ header: { content: "Site" }, render: (site) => site.url },
]}
/>
</div>
);Usage inside a picker (selection only)
The bar can also act as a pure selection control — without actions — to power multi-select inside a picker. Here it sits at the top of a Site picker-style dropdown: the tri-state column-header checkbox selects the current page, and once a row is selected the bar's scope menu extends the selection to the whole set ("Select all items"). The picker's own footer confirms it — "Add 4 sites". The SitePicker component itself is single-select; this is a composition built from BaseTablePicker + Table + BulkActionBar.
const [sort, setSort] = useState<SortField<Site>>({ property: "name", direction: "asc" });
// Seed the picker with two rows already selected, as an "edit existing selection" dialog would.
const bulk = useBulkActions(allSites.length, (s: Site) => s.id, { initialSelectedIds: [1, 2] });
const total = allSites.length;
const pageItems = allSites; // the picker shows the whole set on one page
const barProps = bulk.getBarProps(pageItems);
const { allSelected: pageAllSelected, someSelected: pageSomeSelected } =
bulk.pageState(pageItems);
return (
<BaseTablePicker
itemsCount={allSites.length}
totalItems={total}
selectedItem={barProps.selectedCount > 0 ? allSites[0] : undefined}
selectedItemStringify={() => `${barProps.selectedCount} sites selected`}
buttonIcon={<IconSite />}
texts={{
buttonContentNoItemSelected: "Select sites",
showingXOfYItems: (showing, t) => `Showing ${showing} of ${t} sites`,
}}
contentItems={(_firstFocusableRef, close, tableClassName) => (
<>
{/* Selection only — no actions. The picker's own footer confirms the selection. */}
<BulkActionBar {...barProps} onDismiss={bulk.clear} />
<Table
items={allSites}
loading={false}
sort={sort}
setSort={(property, direction) =>
setSort({
property,
direction: property === sort.property ? invertDirection(sort.direction) : direction,
})
}
rowKey={(site) => site.id}
highlightRow={(site) => bulk.isSelected(site) === true}
className={tableClassName}
columns={[
{
header: {
contentNode: (
<BaseCheckbox
aria-label="Select all rows on this page"
checked={pageAllSelected}
indeterminate={pageSomeSelected}
onChange={() =>
pageAllSelected ? barProps.onDeselectPage() : barProps.onSelectPage()
}
/>
),
},
render: (site) => (
<BaseCheckbox
aria-label={`Select ${site.name}`}
checked={bulk.isSelected(site) === true}
onChange={() => bulk.toggleRow(site)}
/>
),
options: { width: 48, align: "center" },
},
{
header: { property: "url", content: "Site", defaultSortDirection: "asc" },
render: (site) => site.url,
options: { isKeyColumn: true },
},
]}
/>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
padding: "0.75rem",
}}
>
<Button onClick={close}>Cancel</Button>
<Button variant="primary" disabled={barProps.selectedCount === 0} onClick={close}>
Add {barProps.selectedCount} sites
</Button>
</div>
</>
)}
/>
);Properties
| Property | Description | Defined | Value |
|---|---|---|---|
selectedCountRequired | numberNumber of currently selected items across all pages. | ||
totalCountRequired | numberTotal number of items in the current filtered set (the "(T)" in "Select all items (T)"). | ||
selectedOnPageCountRequired | numberNumber of selected items on the current page, driving the page-scoped commands. | ||
pageItemCountRequired | numberTotal number of items on the current page. | ||
onSelectPageRequired | functionSelects every item on the current page. | ||
onDeselectPageRequired | functionDeselects every item on the current page. | ||
onSelectAllRequired | functionSelects the entire filtered set (a server-side predicate, not an enumerated list). | ||
onDeselectAllRequired | functionDeselects the entire selection. | ||
onDismissRequired | functionClears the selection and collapses the bar (the `×` button, and Escape with the menu closed).
Move focus on from here — the bar unmounts. | ||
actionsOptional | object[]Actions for the selection, in the order given. Keep to three or fewer or the bar wraps. | ||
destructiveActionOptional | objectThe destructive action (e.g. Delete), rendered apart in its own divider group. | ||
viewSelectedOnlyOptional | objectRenders a "View selected only" toggle before the dismiss button. Narrowing the rows is the
consumer's job; with it on and nothing selected, show the table's empty state, not a blank table. | ||
data-observe-keyOptional | stringUnique string, used by external script e.g. for event tracking | ||
classNameOptional | stringCustom className that's applied to the outermost element (only intended for special cases) | ||
styleOptional | objectStyle object to apply custom inline styles (only intended for special cases) |